feat(ai): tier-3 supervised/four-eyes approval split — backend core - #3175
Open
ToddHebebrand wants to merge 17 commits into
Open
feat(ai): tier-3 supervised/four-eyes approval split — backend core#3175ToddHebebrand wants to merge 17 commits into
ToddHebebrand wants to merge 17 commits into
Conversation
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… scopes Adds TIER3_FOUR_EYES_ACTIONS/TOOLS and TIER3_SUPERVISED_ACTIONS/TOOLS plus resolveApprovalScope() per the 2026-08-05 tier3-supervised-four-eyes design (§3.1/§3.2): within tier 3, four_eyes requires a second approvals:decide holder; everything else is supervised (requester self-approves). checkGuardrails now surfaces approvalScope on both tier-3 return branches. Exports getAllRegisteredToolNames() from aiTools.ts (core registry + M365/Google tier maps) so the exhaustiveness contract test can walk every real tool. s1_threat_action gets a new per-action split in TIER3_ACTIONS (kill/quarantine supervised, rollback four_eyes) since its action enum is a real discriminator, unlike s1_isolate_device's boolean isolate flag which stays whole-tool supervised per spec §3.1. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…st columns Adds approval_scope/classification_version (immutable tier-3 classification from checkGuardrails), approval_expires_at/release_by (split pending-approval vs execution-lease deadlines), and effect_digest (four_eyes content-pinning hash) to action_intents, per the tier3-supervised-four-eyes-split design. Extends action_intents_immutable_trg to cover the three immutable columns; release_by/approval_expires_at stay mutable for lifecycle stamping. Registers the new columns in the export-policy allowlist and updates the schema contract test's column-list assertion. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
resolveIntentApprovers counted disabled/invited users as eligible Tier-3 action-intent approvers on both the org-member and partner-axis candidate queries, inflating the four-eyes approver set and wrongly suppressing the sole-operator fallback. Join `users` and gate on status='active' on both queries. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…t deadlines Task 4 of the tier3-supervised-four-eyes backend plan. createActionIntent now reads checkGuardrails' approvalScope, stamps approvalScope/classificationVersion on the intent, and short-circuits supervised intents to a single requester-owned approval row (no approvals:decide required, no push). Four-eyes chat deadlines extend to 60 minutes; supervised keeps the existing 5-minute window. Legacy expiresAt is dual-written alongside the new approvalExpiresAt column for rolling-upgrade compat (removal tracked as Plan 3 cleanup). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Extract insertSingleApproverRow(userId) helper shared by the supervised short-circuit and the four_eyes sole-operator branch — both previously had byte-identical 8-line blocks to insert one approval_requests row and derive approvalRequestIds/requesterApprovalRequestId/fanOutUserIds. Behavior unchanged. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
pending_approval intents now expire on approval_expires_at while approved intents expire on release_by (falling back to expires_at for legacy rows), in both the expiry reaper and the release worker's claim CAS. The approve fan-in stamps release_by = now() + RELEASE_LEASE_MS (10m) on an approval win, so an intent approved just before approval_expires_at gets a fresh lease instead of being reaped for a deadline that no longer governs it (the "59:59 trap"). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Tier-3 SUPERVISED intents let the requester self-decide with no WebAuthn assertion, gated on a live re-check of the underlying tool's RBAC permission (not approvals:decide) via the same buildAuthContextForIntent + checkToolPermission pair the release worker uses. A non-requester is refused (403 not_requester) even if they hold approvals:decide. The whole decide write — approval-row CAS, ai_tool_executions mirror, and the action-intents fan-in (intent CAS + release_by + sibling expiry + intent_approved outbox insert) — now commits as ONE system-scoped transaction, so a fault anywhere inside rolls everything back and the caller gets a retryable 500 instead of a half-applied decision (approval decided with no intent/outbox follow-through). report-suspicious's intent-rejection block gets the same fail-loud treatment instead of swallowing the error. Extends aiAgentSdk.ts's ApprovalMethod audit union with 'supervised_self' so the postToolUse audit event distinguishes a supervised self-decide from a four_eyes approval via the approvals surface. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…, enforcing floor Fix round 1 on the supervised plain-decide + atomic-decide-transaction work: 1. report-suspicious: fold the approval-row 'reported' flip into the SAME transaction as the intent CAS + sibling expiry. Previously the flip committed separately BEFORE that transaction opened, so a throw inside it left the row 'reported' with the intent permanently stranded pending_approval (a retry's pre-fetch saw status!=='pending' and never re-entered the fan-in). A rollback now restores 'pending' too. 2. Lock-order inversion: both the decide-write transaction and report-suspicious's intent-reject transaction now take a `SELECT ... FOR UPDATE` lock on the intent row FIRST, before touching approval_requests — matching report-suspicious/intentExpiryReaper's existing order. The decide handler previously locked its own approval row first and the intent second, the opposite order, which could deadlock (Postgres 40P01) against a concurrent report-suspicious/reaper transaction. 3. The ai_tool_executions mirror (system-scoped, no RLS) now carries an explicit `exists(...)` tenant/linkage guard in its UPDATE's WHERE clause — verifying the execution's ai_sessions row belongs to the same user as the approval_requests row being decided — instead of relying solely on the (already-trusted, but app-layer-only) executionId FK value. 4. New `approvalsDecideSupervised.integration.test.ts`: drives the real approve/deny route against genuine role/permission state (not the mocked buildAuthContextForIntent/checkToolPermission the unit suite stands in for) — happy path (200, intent approved, outbox written) and a revoked-permission path (403, nothing written). 5. Adjudicated requirement: a supervised decide now checks whether the partner's authenticator policy is actively enforcing before skipping the assurance ladder. When enforcing, the requester goes through the same ladder a four_eyes sole-operator self-approve does (WebAuthn L3 satisfies it); non-enforcing partners keep the unchanged plain-click path. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Adds effectDigest.ts (SHA-256 over resolved tool-specific target content: run_script body, quote send revision, invoice/contract state, org status). createIntent computes and stores it for four_eyes intents only, inside the creation transaction; the release worker recomputes it immediately before execution and CASes to failed:content_changed on a mismatch, closing the TOCTOU gap where an approver signs off on a reference whose target mutates during the (up to 60-minute) four_eyes approval window. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…t-neutral mount GET /pending now joins action_intents and drops rows whose intent is no longer pending_approval, or whose caller no longer qualifies live (four_eyes: current approvals:decide + org access; supervised: still the requester) — a demoted approver stops seeing (and count-ing) a row it can no longer decide. Adds keyset (createdAt, id) pagination (limit capped at 50, opaque cursor, additive nextCursor field) and a new GET /pending/count using the identical filter set. Both routes are now also mounted transport-neutrally at /api/v1/approvals, alongside the existing /api/v1/mobile/approvals alias, with the same mobileDeviceBlockedMiddleware so a blocked device can't dodge the check via the new prefix. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…le four-eyes contract - resolveApprovalScope now takes `input` and resolves manage_organizations: update_org (four_eyes only when `status` is present, else supervised) and s1_isolate_device (four_eyes only on isolate:false containment release, else supervised) from argument content instead of a static tool/action table. Both are exempted from the contract test's static exactly-one-scope invariant via new TIER3_INPUT_AWARE_ACTIONS/TIER3_INPUT_AWARE_TOOLS sets and covered by dedicated both-branches tests. - aiAgentSdk's chat SSE approval_required event for the tier-3 durable-intent path now carries approvalScope alongside the existing selfApprovalRequestId, so the web approval card can distinguish a supervised self-approve from a four_eyes sole-operator self-approve (AiStreamEvent type updated in packages/shared). Push dispatch is unchanged: it was already fully owned and gated to four_eyes inside intentService.ts's createActionIntent; the only push call site in aiAgentSdk.ts is the unrelated Tier-2 legacy per_step bridge. - New durable-executable contract test (intentReleaseWorker.durable.contract.test.ts) asserts every four_eyes-classified tool (whole-tool or per-action) is releasable by the headless worker (isSessionRequiredForRelease, newly exported) rather than stuck needing a live chat session — it passes as-is, since Phase 2's Google/M365 headless dispatch already covers every four_eyes member. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
fourEyesTools was built only from the static TIER3_FOUR_EYES_TOOLS/ TIER3_FOUR_EYES_ACTIONS tables, so s1_isolate_device (moved to TIER3_INPUT_AWARE_TOOLS) and manage_organizations (only covered by accident via create_org) were structurally invisible to the durable-executable contract even though they can resolve four_eyes at runtime via resolveApprovalScope's input-aware overrides. Union in TIER3_INPUT_AWARE_TOOLS and the tool-name projection of TIER3_INPUT_AWARE_ACTIONS — a conservative superset is correct since the assertion is about session-requirement, not resolved scope. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Adds intentSupervisedFourEyes.integration.test.ts, covering what the existing decide-path integration suites don't: a four_eyes intent fans out to both other eligible admins (never the requester), an approve at t+30min still succeeds and releases inside the new 60-minute four_eyes window (via direct DB timestamp manipulation, not a mocked clock), and a disabled second admin correctly engages the four_eyes sole-operator fallback instead of reclassifying to supervised. approvalsDecideSupervised.integration.test.ts already covers the supervised happy-path + revoked-permission; approvalsDecideAtomicity. integration.test.ts already covers the fan-in fault-injection rollback. Named in vitest.integration.config.ts's include list for discoverability (the file already lives under src/__tests__/integration/, which both that config's glob and vitest.config.ts's wholesale exclude already cover automatically). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…four-eyes split execute_command was reclassified `supervised` by an earlier task in this plan (Task 1, aiGuardrails.ts), which collapses its approval fan-out to exactly one requester-owned row. intentFanout.integration.test.ts and intentSelfApproveGuard.integration.test.ts still seeded intents with execute_command and asserted the pre-split multi-approver/four_eyes sole-operator behavior, so they were either failing outright (wrong row count/owner) or passing for the wrong reason (silently routed through the unrelated supervised decide branch instead of the four_eyes one they exist to cover). Switches every intent-creation call in both files to restore_snapshot (four_eyes-classified), the same fix approvalsDecideAtomicity.integration.test.ts already made for its own fixture. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Final whole-branch review turned up three IMPORTANT gaps: 1. The inline chat-session release path (aiAgentSdk.ts) won the approved->executing CAS and revalidated the requester's authorization, but never recomputed the pinned effect digest the way jobs/intentReleaseWorker.ts does — a four_eyes reference-approval whose target drifted during the approval window could execute stale content if it happened to complete inline instead of via the durable worker. Mirror the worker's computeEffectDigest recheck and CAS to failed:content_changed on mismatch. 2. Supervised approves polluted the four_eyes sole-operator audit signal: a supervised intent's sole approval row is always requester-owned, so every ordinary supervised approve was audited as self_approved_sole_operator (an outcome meant to flag four_eyes L3 self-approval specifically). Gate soleOperatorApproval / details.soleOperator on approvalScope === 'four_eyes' in both routes/approvals.ts and services/actionIntents/intentService.ts; supervised approves now audit as approved with details.approvalMethod: 'supervised_self'. 3. The reaper's pending_approval predicate used bare approval_expires_at < now(), which is SQL NULL (never true) for legacy writer rows that never got the column backfilled — those rows could never be reaped even once their legacy expires_at deadline passed. Fall back to COALESCE(approval_expires_at, expires_at), mirroring the approved branch's existing construct. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Deploying breeze with
|
| Latest commit: |
ae5acdf
|
| Status: | ✅ Deploy successful! |
| Preview URL: | https://8742f544.breeze-9te.pages.dev |
| Branch Preview URL: | https://toddhebebrand-tier3-supervis.breeze-9te.pages.dev |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What
Splits AI Tier-3 approvals into two scopes (spec:
docs/superpowers/specs/ai-mcp/2026-08-05-tier3-supervised-four-eyes-split-design.md, in this PR):approvals:decidedecides — financial issuance/payment, tenant-shape changes, M365/Google identity mutations, restore/DR, containment release, computer control. Chat approval window raised 5→60 min for these; the durable release worker executes late approvals under a 10-min release lease.Motivation: for orgs with ≥2 admins and no mobile app (all self-hosters — the app isn't distributable yet), every Tier-3 action fanned out four-eyes with push-only notification and a 5-minute window → guaranteed expiry. Reported by a self-hosted partner 2026-08-05.
Also fixes (found during design review + implementation)
content_changedon drift.approval_expires_at(pending) +release_bylease (approved) — an approval at 59:59 no longer gets reaped before the worker claims it; legacy-row COALESCE fallbacks for rolling upgrades.GET /pending/count; transport-neutral/api/v1/approvalsmount (mobile alias kept, response shape backward-compatible with additivenextCursor).Reviewer callouts
action_intent.self_approved_sole_operatornow fires only for genuine four-eyes sole-operator self-approvals; supervised decides auditapprovalMethod: 'supervised_self'.approvedflag semantics per [API] Decide: should read-only execute_command types auto-execute under the default per_step approval mode? #3130 unchanged.2026-08-14-intent-approval-scope-and-deadlines.sql(forward-dated to sort after existing 2026-08-13; idempotent; extends the immutability trigger). All five new columns registered in the export policy.MCP_APPROVAL_REQUIRED).approvalScope; the in-chat supervised card is actionable today via the existing sole-operator card path).request_elevationare four_eyes pending explicit demotion decisions.Testing
Full unit suite (20k+ tests), RLS suite, integration suite (incl. new
intentSupervisedFourEyes,approvalsDecideSupervised, inverted atomicity test, reaper legacy-row cases) green locally;db:check-driftclean. Contract tests: classification exhaustiveness (every effective-tier-3 surface explicitly scoped), durable-executable (four-eyes ⊆ headless-executable, input-aware tools included), export policy.🤖 Generated with Claude Code